Skip to content

computer: Detach codemode execs from whoever started them, and hear how they exit - #322

Open
scuffi wants to merge 2 commits into
mainfrom
computer/exec-detach
Open

scuffi wants to merge 2 commits into
mainfrom
computer/exec-detach

Conversation

@scuffi

@scuffi scuffi commented Nov 12, 2026 •

Copy link
Copy Markdown
Member

Codemode execs can already wait for a person: a host module calls ctx.interrupt(), the exec pauses durably, and someone answers with runtime.respond(), even after a deploy. But whoever started the exec still had to wait on it for the result. The exec tool returns only when its exec ends, so an agent that starts a refund needing approval is stuck inside its tool call until a manager answers. It can't tell the customer what's happening, and if the turn is aborted, the exec is killed with it.

This lets an exec detach: the caller stops waiting on it, the exec keeps going, and when it exits its backend hands the outcome to a function you give it. The exec tool can detach on its own once its exec waits for an answer.

Usage

There are two halves. The backend says where detached execs report back, with onDetachedExit next to its existing onWait. Detaching is a capability of the backend: only CodemodeBackend has it, and only when it has somewhere to send exits. The backend is built when the Durable Object starts, so the hook is there to receive an exit even straight after a deploy. Whoever starts an exec decides whether to detach it.

const workspace = new Workspace({
  storage: ctx.storage,
  backends: [
    new CodemodeBackend({
      loader: env.LOADER,
      onInterrupt: "replay",
      onWait: (call) => notifyManager(call), // existing: a call started waiting
      onDetachedExit: async (exec) => {
        // exec: { id, backend, status, exitCode, result?, metadata, stdout, stderr, exitedAt }
        await inbox.save(exec);
      },
    }),
  ],
});

// From an agent: detach a tool call once its exec waits for an answer...
createExecTool({ workspace, detach: true });
// ...or also once it has run for 45 seconds.
createExecTool({ workspace, detach: { afterMs: 45_000 } });

// From your own code: detach one exec.
const exec = await workspace.runtime.exec(source);
if (!(await workspace.runtime.detachExec(exec.id))) {
  // It had already exited: read its result as usual.
}

detach works the same through createAITools, the TanStack and pi tool sets, and defineExec. Without it, the tool behaves as before.

What the model sees

A tool call whose exec detaches returns straight away:

{
  "command": "export default async function () { … }",
  "cwd": null,
  "backend": "codemode",
  "executionId": "ex_123",
  "status": "waiting",
  "stdout": "Found order ORD-1042\n",
  "stderr": "",
  "waiting": [{ "callId": "ex_123:2", "payload": { "kind": "refund", "amountCents": 12900 } }],
  "note": "The run is still going. It is waiting for an answer to the calls in `waiting`, and carries on by itself once they are answered. Don't run it again: you'll get a message with its outcome when it ends."
}

The tool description says the same up front, so the model tells the user what's happening instead of running the code again. Aborting the turn after that no longer kills the exec. An exec that ends before it detaches returns its result exactly as before.

The loop

sequenceDiagram
  participant Model
  participant exec as exec tool
  participant Exec
  participant Backend
  participant App
  Model->>exec: issue refund
  exec->>Exec: start
  Exec-->>exec: waits in ctx.interrupt()
  exec->>Backend: detachExec(id)
  exec-->>Model: { executionId, status: "waiting" }
  Note over Model: tells the customer, turn ends
  App->>Exec: respond(approved)
  Exec-->>Backend: exits
  Backend-->>App: onDetachedExit(exec)
  App->>Model: new turn with the outcome
Loading

The app's part is small: save what onDetachedExit hands you, and turn it into a message for the agent. If the Durable Object might be evicted, call runtime.recover() from an alarm while execs are waiting. That resumes them after a deploy and delivers any exit onDetachedExit missed. Replay already needs this for ctx.interrupt().

Guarantees

  • Every exec's outcome goes to exactly one place. detachExec() checks that the exec is still running and marks it in one transaction, and returns false if it has already exited. So an outcome reaches whoever reads the result or onDetachedExit, never both and never neither. The tool handles the edge case too: if the exec exits while a detach is going through, the tool waits to learn whether the detach took and replies to match.
  • onDetachedExit doesn't lose exits. The exit is saved in the same transaction that records it, and deleted once onDetachedExit returns. If onDetachedExit throws, or the object restarts first, it's called again when the backend next connects or from runtime.recover(). Delivery is at least once, so key on exec.id.
  • Execs that never detach cost nothing extra.

Other changes

  • Every codemode exec's metadata is now saved with the exec. It used to be saved only for execs that could resume, so an onInterrupt: "fail" exec that a restart cut off would have reported empty metadata.
  • runtime.backends() reports detachable for each backend.
  • runtime.recover() now delivers missed exits for CodemodeBackend. It used to have nothing to do for this backend.

Limits

  • CodemodeBackend is the only backend that can detach for now, and only with onDetachedExit. A tool with detach leaves execs on other backends attached. Building a tool with detach fails at build time if no backend can detach. runtime.detachExec() throws for an exec on a backend that can't.
  • Everything here is experimental, like ctx.interrupt().

For reviewers

  • To learn that its exec is waiting, the tool polls runtime.pending() every 250 milliseconds while it streams output, and stops as soon as the exec ends or detaches. A wait event on the exec's stream would avoid polling, but it would change the public WorkspaceRuntimeEvent union, which a lot of code switches on.
  • The saved exit carries the result itself. Exited execs are pruned after an hour, so a later getExec() can't be relied on.
  • The two commits split the backend and runtime part, onDetachedExit and detachExec(), from the tool part, exec: { detach }.
  • A detached reply is a new shape in the public ExecToolOutput type, with no exitCode. Code that narrows it with "error" in output and then reads exitCode stops typechecking, as examples/rlm did; that commit fixes it. Reusing the result shape with exitCode: null would avoid that, but a detached reply would then look like a "still running" update to code that checks exitCode === null, such as the TanStack tools.

Trying it

The script-runner test "lets the exec tool detach from a run once it waits, and hears how it ended later" runs the whole loop against a real Workspace:

npm run build --workspace @cloudflare/dofs --workspace @cloudflare/computer-rpc
cd packages/computer
npx vitest run --config vitest.config.script-runner.ts -t "detach|exit"

A follow-up pull request adds examples/detached-approvals: two support agents side by side, one attached and one detached, both needing a manager to approve refunds.

Tests

  • Script-runner tests check that onDetachedExit hears about:
    • an exec that completes;
    • one killed while it waits;
    • one a restart failed;
    • an exit it missed, after the hook threw or the object restarted;
    • nothing for an exec that never detached.
  • A script-runner test runs the full tool loop.
  • Unit tests cover:
    • detachExec() on an exec that's running, one that has exited, and on a backend without onDetachedExit;
    • each way a tool call detaches or doesn't;
    • both sides of the exit-while-detaching race;
    • aborting the turn after a detach;
    • backends that can't detach;
    • detach passing through the AI SDK and TanStack tool sets.

Docs

  • docs/05_runtime_interface.md gets a "Detaching" section covering onDetachedExit and detachExec().
  • docs/09_tool_interface.md gets a "Detaching" section for the tool.
  • docs/17_isolate_javascript.md points to both from the section on waiting.
  • The package README's status table lists both parts.

Follow-ups

  • Output a codemode exec prints just before it waits only appears once the exec resumes, so a detached reply can miss the last lines. This happened before this change too.
  • The note is fixed text. Apps that word things differently may want to set it.
  • The shell and container backends could support detaching later.

@changeset-bot

changeset-bot Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 791816d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 5 packages
Name Type
@cloudflare/computer Patch
@cloudflare/dofs Patch
@cloudflare/computer-rpc Patch
@cloudflare/computerd Patch
@cloudflare/remote-exec Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@scuffi
scuffi force-pushed the computer/exec-detach branch from 005907b to 3440fdc Compare November 12, 2026 14:30
@scuffi scuffi changed the title computer: Detach the exec tool from a waiting run, and hear how runs end computer: Detach execs from whoever started them, and hear how they exit Nov 12, 2026
@scuffi
scuffi force-pushed the computer/exec-detach branch from 3440fdc to 2ed7898 Compare November 12, 2026 14:44
@pkg-pr-new

pkg-pr-new Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@cloudflare/computer@322

commit: 791816d

@scuffi
scuffi marked this pull request as ready for review November 12, 2026 22:53
An exec that waits for a person in ctx.interrupt() can take hours.
Whoever started it often can't wait that long: an agent wants to tell
the user what's happening and carry on, and a deploy can end the
request that was waiting. Until now nothing told the app when such an
exec finally exited.

CodemodeBackend now takes an onDetachedExit function, and the runtime
gains detachExec(id). Detaching says that nothing waits on the exec
any more. The exec keeps going, and when it exits, onDetachedExit
hears its status, exit code, result, metadata and the last 16 KiB of
each output stream. detachExec() checks that the exec is still running
and marks it in one transaction, and returns false if it had already
exited, so every exec's outcome goes either to whoever reads its
result or to onDetachedExit, never both and never neither.

The hook sits on the backend, beside onWait, because detaching is a
capability of the backend: a backend without it can't detach, and
runtime.backends() reports which can. CodemodeBackend saves a detached
exec's exit in the same transaction that records the exit, calls
onDetachedExit, and deletes the saved exit once the hook returns. An
exit whose hook threw, or whose object restarted first, is delivered
again when the backend next connects or from runtime.recover(), so
delivery is at least once, keyed by the exec's id. Execs that were
never detached save nothing.

A run's metadata is now saved with every run, not only with runs that
can resume, so an onInterrupt "fail" run that a restart cut off still
reports whose it was.

Detaching is experimental, like waiting for an answer.
The exec tool returned only when its run ended. A run waiting for an
approval held the agent's turn for as long as the approval took, so the
agent couldn't tell anyone what it was doing, and aborting the turn
killed the run.

createExecTool, and the exec option of every tool set, take detach. With
detach: true, the tool checks runtime.pending() for its own run's
waiting calls and, once one appears, detaches the run through
runtime.detachExec() and returns. With detach: { afterMs }, it also
detaches a run that has gone on that long. The reply carries the run's
executionId, a status of "waiting" or "running", the output so far, the
waiting calls and a note telling the model that the outcome arrives
later. The run's exit then goes to its backend's onDetachedExit, and an
abort of the model turn leaves the run alone.

A run that ends before it detaches returns as before. If the run ends
while its detach is going through, the tool waits to learn whether the
detach took: if it did, the tool replies as detached, since the exit
goes to onDetachedExit. Runs on backends that can't detach stay
attached, and a tool with no backend that can detach refuses detach when
it is built. Detaching is experimental.

A detached reply is a new shape of ExecToolOutput, with no exitCode. The
rlm example's output sanitizer read exitCode from anything that wasn't
an error, so it now treats any reply without one as a failed run. It
never detaches, so its behavior doesn't change.
@scuffi
scuffi force-pushed the computer/exec-detach branch from 2ed7898 to 791816d Compare November 13, 2026 11:16
@scuffi scuffi changed the title computer: Detach execs from whoever started them, and hear how they exit computer: Detach codemode execs from whoever started them, and hear how they exit Nov 13, 2026

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

Devin Review

Comment on lines +267 to +275
if (detach !== undefined) {
if (detachExec === undefined || pending === undefined) {
throw new Error("createExecTool: detach needs a runtime with detachExec() and pending()");
}
if (detachable.size === 0) {
throw new Error(
"createExecTool: detach needs a backend that can detach, such as a CodemodeBackend with onDetachedExit",
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Detaching unavailable through Workspace clients

With a getWorkspace() client, defineExec rejects detach because the client runtime lacks pending and detachExec. The client adapter exposes neither method, so agent tools cannot use detaching through this public client.

Learn more

A loaded getWorkspace() client wraps the underlying runtime through makeRuntimeClient. That wrapper only forwards exec, getExec, killExec, disposeExec, and backends. A detaching tool reads the backend list successfully, but rejects construction because the runtime wrapper has neither pending() nor detachExec(). The underlying WorkspaceRuntimeStub already implements detachExec(), but the wrapper drops it.

Example: Configure a CodemodeBackend with onDetachedExit, then create a tool with const ws = await getWorkspace(stub); createExecTool({ workspace: ws, detach: true }). Construction throws detach needs a runtime with detachExec() and pending() instead of offering the exec tool.

Recommended fix: Add pending() and detachExec() to WorkspaceRuntimeClient, UnderlyingRuntime, and makeRuntimeClient; forward them on both local and remote paths. The remote stub also needs pending() if it does not already expose it. Cover a loaded local and remote getWorkspace() client in tests.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +1776 to +1783
if (exit !== undefined) {
this.#host.db.run(
`INSERT OR REPLACE INTO workspace_runtime_detached_exits (backend, execution_id, data)
VALUES (?, ?, ?)`,
this.#backendId,
record.id,
JSON.stringify(exit),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Reused IDs erase pending exit notifications

If onDetachedExit fails, disposing that exec and reusing its ID lets the next detached exit replace the saved one. disposeExec removes no pending exit, so the first outcome is lost.

Learn more

A detached exec's exit is kept in workspace_runtime_detached_exits until onDetachedExit succeeds. If the hook throws, deliverExits retains that row. disposeExec deletes its execution without deleting or reserving the pending exit's ID. A new exec can reuse that ID, and its terminal transaction replaces the previous exit in the same table.

Example: Run and detach id: "refund-42"; its callback throws. Dispose the finished exec, then launch and detach another refund-42. When the second exits, the saved outcome of the first is overwritten and cannot be delivered on recover().

Recommended fix: Prevent reusing an ID while an undelivered exit exists, or give pending exits a durable unique generation key rather than replacing rows keyed solely by execution ID. Test callback failure, disposal, ID reuse, and recovery.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +164 to +178
/**
* Called once a detached exec exits, however it exits, with how it
* ended. An exec detaches through `runtime.detachExec()`, or the exec
* tool's `detach`, when nothing should wait on it any more, such as
* one an agent left waiting for an approval. Nothing else hears how
* it ended, so this is where the outcome goes back to whoever started
* it. Without this, the backend's execs can't detach.
*
* The backend saves the exit in the same transaction that records
* it, and deletes it once this returns. If this throws, or the object
* restarts first, it's called again when the backend next connects or
* from `runtime.recover()`, so it may hear about an exec more than
* once: key on its `id`. Experimental: it may change in any release.
*/
onDetachedExit?: (exec: WorkspaceRuntimeExecExit) => void | Promise<void>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Description uses an obsolete callback API

The PR description configures Workspace with detached: { onExit }, while the implementation accepts onDetachedExit on CodemodeBackend. The usage example needs updating if the implementation is final.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant